using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
///
/// Unit tests for the method-node mirror surface added in PR-9 (Issue #281). Live-call
/// coverage of against an upstream
/// server lands in integration tests against opc-plc; these unit assertions cover the
/// capability surface, the
/// DTO shape, the DTO shape, and the back-compat default
/// no-op for .
///
[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(
"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(async () =>
await drv.CallMethodAsync(
"ns=2;i=1", "ns=2;i=2",
Array.Empty(),
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");
}
///
/// Pre-PR-9 minimum implementation. Verifies the default no-op for
/// RegisterMethodNode doesn't break existing builders that don't know about
/// the new surface.
///
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 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);
}
}