Adds the 9th capability interface (IMethodInvoker) so the OPC UA Client driver can mirror upstream OPC UA Method nodes into the local address space and forward Call invocations as Session.CallAsync. Method-bearing servers (e.g. ProgramStateMachine, Acknowledge / Confirm methods, custom control surfaces) now show up downstream instead of being silently filtered out. - Core.Abstractions: IMethodInvoker + MethodCallResult; default no-op IAddressSpaceBuilder.RegisterMethodNode + MirroredMethodNodeInfo + MethodArgumentInfo. Default impls keep tag-based drivers and existing builders compiling without forced overrides. - OpcUaClientDriver: BrowseRecursiveAsync now lifts the Method node-class filter; for each method it walks HasProperty to pick up InputArguments + OutputArguments and decodes the Argument extension objects into MethodArgumentInfo. Status-codes pass through verbatim (cascading quality), local NodeId-parse + lost-session faults surface as BadNodeIdInvalid / BadCommunicationError. - 7 new unit tests cover the capability surface, the DTO shapes, and the back-compat default no-op for RegisterMethodNode. Suite green at 160/160. Server-side OnCallMethod dispatch (wiring local MethodNode handlers to IMethodInvoker.CallMethodAsync inside DriverNodeManager) is deferred to a follow-up — the driver-side surface + browse mirror ship cleanly here. Closes #281 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
182 lines
8.7 KiB
C#
182 lines
8.7 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the method-node mirror surface added in PR-9 (Issue #281). Live-call
|
|
/// coverage of <see cref="OpcUaClientDriver.CallMethodAsync"/> against an upstream
|
|
/// server lands in integration tests against opc-plc; these unit assertions cover the
|
|
/// <see cref="IMethodInvoker"/> capability surface, the <see cref="MirroredMethodNodeInfo"/>
|
|
/// DTO shape, the <see cref="MethodArgumentInfo"/> DTO shape, and the back-compat default
|
|
/// no-op for <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientMethodInvokerTests
|
|
{
|
|
[Fact]
|
|
public void OpcUaClientDriver_implements_IMethodInvoker()
|
|
{
|
|
// The driver is the only built-in IMethodInvoker — tag-based drivers (Modbus, S7,
|
|
// FOCAS, Galaxy, AB-CIP, AB-Legacy, TwinCAT) intentionally don't expose method nodes.
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-method");
|
|
drv.ShouldBeAssignableTo<IMethodInvoker>(
|
|
"OPC UA Client driver is the cross-driver method-bearing capability");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallMethodAsync_without_initialize_throws_InvalidOperationException()
|
|
{
|
|
// Same lifecycle invariant as the other capability surfaces — the session has to be
|
|
// up before a call can land. Surfaces a clean exception rather than NullReference.
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
|
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
|
await drv.CallMethodAsync(
|
|
"ns=2;i=1", "ns=2;i=2",
|
|
Array.Empty<object>(),
|
|
TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public void MirroredMethodNodeInfo_carries_browse_identity_owner_and_argument_schemas()
|
|
{
|
|
// Exercises the address-space builder's contract for a method node — the DTO fields
|
|
// line up with the OPC UA Call request shape (ObjectId + MethodId) and carry the
|
|
// input/output Argument arrays the dispatcher uses to render the local method node's
|
|
// signature.
|
|
var inputs = new[]
|
|
{
|
|
new MethodArgumentInfo("targetTemperature", DriverDataType.Float64, ValueRank: -1, Description: "deg C"),
|
|
};
|
|
var outputs = new[]
|
|
{
|
|
new MethodArgumentInfo("ack", DriverDataType.Boolean, ValueRank: -1, Description: null),
|
|
};
|
|
var info = new MirroredMethodNodeInfo(
|
|
BrowseName: "SetTemperature",
|
|
DisplayName: "Set Temperature",
|
|
ObjectNodeId: "ns=2;i=1",
|
|
MethodNodeId: "ns=2;i=42",
|
|
InputArguments: inputs,
|
|
OutputArguments: outputs);
|
|
|
|
info.BrowseName.ShouldBe("SetTemperature");
|
|
info.MethodNodeId.ShouldBe("ns=2;i=42");
|
|
info.ObjectNodeId.ShouldBe("ns=2;i=1");
|
|
info.InputArguments.ShouldNotBeNull().ShouldHaveSingleItem().Name.ShouldBe("targetTemperature");
|
|
info.OutputArguments.ShouldNotBeNull().ShouldHaveSingleItem().DriverDataType.ShouldBe(DriverDataType.Boolean);
|
|
}
|
|
|
|
[Fact]
|
|
public void MirroredMethodNodeInfo_supports_methods_with_no_arguments()
|
|
{
|
|
// Argument-less methods (e.g. a Reset / Stop button) are common; null arrays mean
|
|
// "no inputs / no outputs" — the dispatcher handles that without complaining.
|
|
var info = new MirroredMethodNodeInfo(
|
|
BrowseName: "Reset", DisplayName: "Reset",
|
|
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=99",
|
|
InputArguments: null, OutputArguments: null);
|
|
|
|
info.InputArguments.ShouldBeNull();
|
|
info.OutputArguments.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void MethodCallResult_carries_status_outputs_and_per_input_argument_codes()
|
|
{
|
|
// The status-code passthrough contract: Bad codes flow verbatim from the upstream
|
|
// so downstream OPC UA clients see canonical service-results (BadMethodInvalid,
|
|
// BadUserAccessDenied, BadArgumentsMissing, …). Outputs + per-arg codes are
|
|
// optional (null when the upstream didn't surface them).
|
|
var goodCall = new MethodCallResult(StatusCode: 0, Outputs: new object[] { 42 }, InputArgumentResults: null);
|
|
goodCall.StatusCode.ShouldBe(0u);
|
|
goodCall.Outputs.ShouldNotBeNull().Length.ShouldBe(1);
|
|
goodCall.InputArgumentResults.ShouldBeNull();
|
|
|
|
const uint BadMethodInvalid = 0x80540000u;
|
|
var badCall = new MethodCallResult(BadMethodInvalid, Outputs: null, InputArgumentResults: null);
|
|
badCall.StatusCode.ShouldBe(BadMethodInvalid);
|
|
badCall.Outputs.ShouldBeNull();
|
|
|
|
const uint BadTypeMismatch = 0x80740000u;
|
|
var argFailure = new MethodCallResult(
|
|
StatusCode: BadTypeMismatch, Outputs: null,
|
|
InputArgumentResults: new[] { 0u, BadTypeMismatch });
|
|
argFailure.InputArgumentResults.ShouldNotBeNull();
|
|
argFailure.InputArgumentResults![0].ShouldBe(0u);
|
|
argFailure.InputArgumentResults![1].ShouldBe(BadTypeMismatch);
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterMethodNode_default_implementation_is_no_op_for_back_compat()
|
|
{
|
|
// Builders that only implement Folder + Variable + AddProperty (the pre-PR-9
|
|
// contract) keep working — the default no-op on RegisterMethodNode means calling
|
|
// it on a minimal builder doesn't throw. This protects every non-OPC-UA-Client
|
|
// builder (Galaxy, Modbus, FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) from forced
|
|
// override pressure.
|
|
IAddressSpaceBuilder builder = new MinimalBuilder();
|
|
var info = new MirroredMethodNodeInfo(
|
|
BrowseName: "Reset", DisplayName: "Reset",
|
|
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=99",
|
|
InputArguments: null, OutputArguments: null);
|
|
|
|
Should.NotThrow(() => builder.RegisterMethodNode(info));
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterMethodNode_can_be_overridden_so_OPC_UA_server_builders_record_the_call()
|
|
{
|
|
// The OPC UA server-side DriverNodeManager will override RegisterMethodNode to
|
|
// materialize a real MethodNode + wire its OnCallMethod handler to the driver's
|
|
// CallMethodAsync. This test models that contract with a recording stub.
|
|
var recorder = new RecordingMethodBuilder();
|
|
IAddressSpaceBuilder builder = recorder;
|
|
|
|
builder.RegisterMethodNode(new MirroredMethodNodeInfo(
|
|
BrowseName: "Start", DisplayName: "Start",
|
|
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=10",
|
|
InputArguments: null, OutputArguments: null));
|
|
builder.RegisterMethodNode(new MirroredMethodNodeInfo(
|
|
BrowseName: "SetSetpoint", DisplayName: "SetSetpoint",
|
|
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=11",
|
|
InputArguments: new[] { new MethodArgumentInfo("sp", DriverDataType.Float32, -1, null) },
|
|
OutputArguments: null));
|
|
|
|
recorder.Calls.Count.ShouldBe(2);
|
|
recorder.Calls[0].BrowseName.ShouldBe("Start");
|
|
recorder.Calls[1].InputArguments.ShouldNotBeNull().ShouldHaveSingleItem().Name.ShouldBe("sp");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pre-PR-9 minimum implementation. Verifies the default no-op for
|
|
/// <c>RegisterMethodNode</c> doesn't break existing builders that don't know about
|
|
/// the new surface.
|
|
/// </summary>
|
|
private sealed class MinimalBuilder : IAddressSpaceBuilder
|
|
{
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
|
=> new StubHandle();
|
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
|
|
|
private sealed class StubHandle : IVariableHandle
|
|
{
|
|
public string FullReference => "stub";
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
|
=> throw new NotSupportedException();
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingMethodBuilder : IAddressSpaceBuilder
|
|
{
|
|
public List<MirroredMethodNodeInfo> Calls { get; } = new();
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
|
=> throw new NotSupportedException();
|
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
|
public void RegisterMethodNode(MirroredMethodNodeInfo info) => Calls.Add(info);
|
|
}
|
|
}
|