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); } }