Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/Fluent/MethodInvocationBuilderExtensionsTests.cs
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

211 lines
9.5 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/
* ======================================================================*/
using System;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server.Fluent;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.Fluent
{
/// <summary>
/// Verifies exclusive complete-result registration across all built-in node-builder families.
/// </summary>
[TestFixture]
[Parallelizable]
public sealed class MethodInvocationBuilderExtensionsTests
{
/// <summary>
/// Verifies that both ordinary overloads reject complete handlers in either registration order.
/// </summary>
/// <param name="family">The public builder entry point exercised by this case.</param>
/// <param name="asynchronous">Whether the ordinary handler is asynchronous.</param>
/// <param name="completeFirst">Whether the complete-result handler is registered first.</param>
[Test]
public void BuiltInBuildersRejectMixedHandlersInEitherOrder(
[Values("predefined", "instance", "reference-child", "resolved")] string family,
[Values] bool asynchronous,
[Values] bool completeFirst)
{
var root = new BaseObjectState(null)
{
NodeId = new NodeId("Root", kNamespace),
BrowseName = new QualifiedName("Root", kNamespace)
};
INodeBuilder<MethodState> builder = CreateBuilder(family, root);
MethodState method = builder.Node;
GenericMethodCalledEventHandler2 ordinary = s_ordinaryHandler;
GenericMethodCalledEventHandler2Async ordinaryAsync = s_ordinaryHandlerAsync;
void RegisterOrdinary()
{
if (asynchronous)
{
builder.OnCall(ordinaryAsync);
}
else
{
builder.OnCall(ordinary);
}
}
ServiceResultException? error;
if (completeFirst)
{
Assert.That(builder.OnCallWithResult(s_completeHandler), Is.SameAs(builder));
error = Assert.Throws<ServiceResultException>(RegisterOrdinary);
Assert.That(method.OnCallMethodWithResultAsync, Is.SameAs(s_completeHandler));
Assert.That(method.OnCallMethod2, Is.Null);
Assert.That(method.OnCallMethod2Async, Is.Null);
}
else
{
RegisterOrdinary();
error = Assert.Throws<ServiceResultException>(() => builder.OnCallWithResult(s_completeHandler));
Assert.That(method.OnCallMethodWithResultAsync, Is.Null);
if (asynchronous)
{
Assert.That(method.OnCallMethod2Async, Is.SameAs(ordinaryAsync));
}
else
{
Assert.That(method.OnCallMethod2, Is.SameAs(ordinary));
}
}
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError));
}
/// <summary>
/// Verifies ordinary registration remains available when no complete-result handler is installed.
/// </summary>
/// <param name="family">The public builder entry point exercised by this case.</param>
/// <param name="asynchronous">Whether the ordinary handler is asynchronous.</param>
[Test]
public void BuiltInBuildersAllowOrdinaryHandlersWithoutACompleteHandler(
[Values("predefined", "instance", "reference-child", "resolved")] string family,
[Values] bool asynchronous)
{
var root = new BaseObjectState(null)
{
NodeId = new NodeId("Root", kNamespace),
BrowseName = new QualifiedName("Root", kNamespace)
};
INodeBuilder<MethodState> builder = CreateBuilder(family, root);
GenericMethodCalledEventHandler2 handler = s_ordinaryHandler;
GenericMethodCalledEventHandler2Async asyncHandler = s_ordinaryHandlerAsync;
INodeBuilder configured = asynchronous ? builder.OnCall(asyncHandler) : builder.OnCall(handler);
Assert.That(configured, Is.SameAs(builder));
Assert.That(builder.Node.OnCallMethodWithResultAsync, Is.Null);
if (asynchronous)
{
Assert.That(builder.Node.OnCallMethod2Async, Is.SameAs(asyncHandler));
}
else
{
Assert.That(builder.Node.OnCallMethod2, Is.SameAs(handler));
}
}
/// <summary>
/// Creates each built-in builder through its public entry point.
/// </summary>
/// <param name="family">The public entry point to use.</param>
/// <param name="root">The owning root of the authored method's node graph.</param>
/// <exception cref="ArgumentOutOfRangeException">The family is not a supported test case.</exception>
private static INodeBuilder<MethodState> CreateBuilder(string family, BaseObjectState root)
{
var namespaces = new NamespaceTable();
var context = new SystemContext(NUnitTelemetryContext.Create())
{
NamespaceUris = namespaces,
TypeTable = new TypeTable(namespaces)
};
var manager = new NodeManagerBuilder(
context, FluentTestNodeManager.Create(kNamespace), kNamespace,
_ => root, id => id == root.NodeId ? root : null!, _ => []);
INodeBuilder parent = manager.Node(root.NodeId);
if (family == "instance")
{
return parent.CreateInstance(
new QualifiedName("Run", kNamespace), static owner => new MethodState(owner)).AsNode();
}
if (family == "reference-child")
{
INodeBuilder<BaseObjectState> group = parent.AddObject("Group");
var method = new MethodState(group.Node)
{
NodeId = new NodeId("Run", kNamespace),
BrowseName = new QualifiedName("Run", kNamespace)
};
group.Node.AddChild(method);
return group.Child<MethodState>(method.BrowseName);
}
var resolved = new MethodState(root)
{
NodeId = new NodeId("Run", kNamespace),
BrowseName = new QualifiedName("Run", kNamespace)
};
root.AddChild(resolved);
if (family == "predefined")
{
return manager.Node(resolved);
}
if (family == "resolved")
{
var facade = new Mock<INodeManagerBuilder>();
facade.SetupGet(value => value.Context).Returns(context);
facade.SetupGet(value => value.NodeManager).Returns(manager.NodeManager);
facade.SetupGet(value => value.DefaultNamespaceIndex).Returns(kNamespace);
return facade.Object.Node(resolved);
}
throw new ArgumentOutOfRangeException(nameof(family));
}
/// <summary>The namespace used for the authored node graph.</summary>
private const ushort kNamespace = 2;
/// <summary>The ordinary synchronous callback used to verify preserved registration identity.</summary>
private static readonly GenericMethodCalledEventHandler2 s_ordinaryHandler =
static (_, _, _, _, _) => ServiceResult.Good;
/// <summary>The ordinary asynchronous callback used to verify preserved registration identity.</summary>
private static readonly GenericMethodCalledEventHandler2Async s_ordinaryHandlerAsync =
static (_, _, _, _, _, _) => new ValueTask<ServiceResult>(ServiceResult.Good);
/// <summary>The complete-result handler whose registration must remain exclusive.</summary>
private static readonly MethodCalledWithResultEventHandlerAsync s_completeHandler =
static (_, _, _, _, _) =>
new ValueTask<MethodInvocationResult>(new MethodInvocationResult(ServiceResult.Good));
}
}