diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs index 7254b8c..cffa031 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs @@ -60,8 +60,90 @@ public interface IAddressSpaceBuilder /// /// void RegisterTypeNode(MirroredTypeNodeInfo info) { /* default: no-op */ } + + /// + /// Register a method node mirrored from an upstream OPC UA server. The method is + /// registered as a child of the current builder scope (i.e. the folder representing + /// the upstream Object that owns the method). Optional surface — drivers that don't + /// mirror methods simply never call it; address-space builders that don't materialise + /// method nodes can leave the default no-op in place. Default implementation drops + /// the call so adding this method doesn't break existing + /// implementations. + /// + /// Metadata describing the method node, including input/output argument schemas. + /// + /// + /// The OPC UA Client driver is the primary caller — it picks up + /// NodeClass.Method nodes during the HierarchicalReferences browse + /// pass, then walks each method's HasProperty references to harvest the + /// InputArguments / OutputArguments property values. + /// + /// + /// The OPC UA server-side DriverNodeManager overrides this to materialize + /// a real MethodNode in the local address space and wire its + /// OnCallMethod handler to the driver's + /// . Other builders (Galaxy, Modbus, + /// FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) ignore the projection because their + /// backends don't expose method nodes. + /// + /// + void RegisterMethodNode(MirroredMethodNodeInfo info) { /* default: no-op */ } } +/// +/// Metadata describing a single method node mirrored from an upstream OPC UA server. +/// Built by the OPC UA Client driver during the discovery browse pass and consumed by +/// . +/// +/// OPC UA BrowseName segment from the upstream BrowseName. +/// Human-readable display name; falls back to . +/// +/// Stringified NodeId of the parent Object that owns this method — the ObjectId +/// argument the dispatcher passes back to . +/// +/// +/// Stringified NodeId of the method node itself — the MethodId argument. +/// +/// +/// Declaration of the method's input arguments, in order. null or empty when the +/// method takes no inputs (or the upstream property couldn't be read). +/// +/// +/// Declaration of the method's output arguments, in order. null or empty when the +/// method returns no outputs (or the upstream property couldn't be read). +/// +public sealed record MirroredMethodNodeInfo( + string BrowseName, + string DisplayName, + string ObjectNodeId, + string MethodNodeId, + IReadOnlyList? InputArguments, + IReadOnlyList? OutputArguments); + +/// +/// One row of an OPC UA Argument array — name + data type + array hint. Mirrors the +/// Opc.Ua.Argument structure but without the SDK-only types so this DTO can live +/// in Core.Abstractions. +/// +/// Argument name from the upstream Argument structure. +/// +/// Mapped local . Unknown / structured upstream types fall +/// through to — same convention as variable mirroring. +/// +/// +/// OPC UA ValueRank: -1 = scalar, 0 = OneOrMoreDimensions, 1+ = array +/// dimensions. Driven directly from the upstream Argument's ValueRank. +/// +/// +/// Human-readable description from the upstream Argument structure; null when the +/// upstream doesn't carry one. +/// +public sealed record MethodArgumentInfo( + string Name, + DriverDataType DriverDataType, + int ValueRank, + string? Description); + /// /// Categorises a mirrored type-definition node so the receiving builder can route it into /// the right OPC UA standard subtree (ObjectTypesFolder, VariableTypesFolder, diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs new file mode 100644 index 0000000..dad5d2d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs @@ -0,0 +1,82 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Driver capability for invoking OPC UA Methods on the upstream backend (the OPC UA +/// Call service). Optional — only drivers whose backends carry method nodes +/// implement it. Currently the OPC UA Client driver is the only implementer; tag-based +/// drivers (Modbus, S7, FOCAS, Galaxy, AB-CIP, AB-Legacy, TwinCAT) don't expose method +/// nodes so they don't need this surface. +/// +/// +/// +/// Per docs/v2/plan.md decision #4 (composable capability interfaces) — the +/// server-side DriverNodeManager discovers method-bearing drivers via an +/// is IMethodInvoker check and routes OnCallMethod handlers to +/// . Drivers that don't implement the interface simply +/// never have method nodes registered for them. +/// +/// +/// The address-space mirror is driven by +/// — drivers register the method node + its InputArguments / +/// OutputArguments properties during discovery, then invocations land back on +/// via the server-side dispatcher. +/// +/// +public interface IMethodInvoker +{ + /// + /// Invoke an upstream OPC UA Method. The driver translates input arguments into the + /// wire-level CallMethodRequest, dispatches via the active session, and packs + /// the response back into a . Per-argument validation + /// errors flow through ; method-level + /// errors (BadMethodInvalid, BadUserAccessDenied, etc.) flow through + /// . + /// + /// + /// Stringified NodeId of the OPC UA Object that owns the method (the ObjectId + /// field of CallMethodRequest). Same serialization as IReadable's + /// fullReferencens=2;s=… / i=… / nsu=…;…. + /// + /// + /// Stringified NodeId of the Method node itself (the MethodId field). + /// + /// + /// Input arguments in declaration order. The driver wraps each value as a + /// Variant; callers pass CLR primitives (plus arrays) — the wire-level + /// encoding is the driver's concern. + /// + /// Per-call cancellation. + /// + /// Result of the call — see . Never throws for a + /// Bad upstream status; the bad code is surfaced via the result so the caller + /// can map it onto an OPC UA service-result for downstream clients. + /// + Task CallMethodAsync( + string objectNodeId, + string methodNodeId, + object[] inputs, + CancellationToken cancellationToken); +} + +/// +/// Result of a single OPC UA Call service invocation. +/// +/// +/// Method-level status. 0 = Good. Bad codes pass through verbatim from the +/// upstream so downstream clients see the canonical OPC UA error (e.g. +/// BadMethodInvalid, BadUserAccessDenied, BadArgumentsMissing). +/// +/// +/// Output argument values in declaration order. null when the upstream returned +/// no output arguments (or returned a Bad status before producing any). +/// +/// +/// Per-input-argument status codes. null when the upstream didn't surface +/// per-argument validation results (typical for Good calls). Each entry is the OPC UA +/// status code for the matching input argument — drivers can use this to surface +/// BadTypeMismatch, BadOutOfRange, etc. on a specific argument. +/// +public sealed record MethodCallResult( + uint StatusCode, + object[]? Outputs, + uint[]? InputArgumentResults); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 4c365b6..6b00207 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// /// public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId) - : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, IDisposable, IAsyncDisposable + : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, IMethodInvoker, IDisposable, IAsyncDisposable { // ---- IAlarmSource state ---- @@ -1349,7 +1349,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, - NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable), + NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method), ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName | BrowseResultMask.NodeClass | BrowseResultMask.TypeDefinition), } @@ -1408,9 +1408,153 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d pendingVariables.Add(new PendingVariable(folder, browseName, displayName, childId, fullName)); increment(); } + else if (rf.NodeClass == NodeClass.Method) + { + // Methods hang off Objects (the parent of this browse step). Walk HasProperty + // to harvest InputArguments / OutputArguments — both are standard properties + // on Method nodes — then project to the address-space builder. Best-effort: + // arguments that fail to read fall through to null so the method still + // registers (the dispatcher returns BadArgumentsMissing if a client tries + // to invoke it without the argument schema). + var (inputArgs, outputArgs) = await ReadMethodArgumentsAsync(session, childId, ct) + .ConfigureAwait(false); + var methodId = BuildRemappedFullName(childId, session.NamespaceUris, + _options.Curation.NamespaceRemap); + var ownerId = BuildRemappedFullName(node, session.NamespaceUris, + _options.Curation.NamespaceRemap); + folder.RegisterMethodNode(new MirroredMethodNodeInfo( + BrowseName: browseName, + DisplayName: displayName, + ObjectNodeId: ownerId, + MethodNodeId: methodId, + InputArguments: inputArgs, + OutputArguments: outputArgs)); + increment(); + } } } + /// + /// Read a method node's InputArguments and OutputArguments properties. + /// Both are standard HasProperty children of any NodeClass.Method node + /// in OPC UA — they carry the array-of-Argument structure the dispatcher needs to + /// surface a callable signature on the local method node. + /// + /// + /// A tuple of (InputArguments, OutputArguments). Either side may be null when + /// the method has no arguments of that kind (the property simply isn't present on the + /// upstream method) or when the read failed — both paths are non-fatal. + /// + private static async Task<(IReadOnlyList?, IReadOnlyList?)> + ReadMethodArgumentsAsync(ISession session, NodeId methodNodeId, CancellationToken ct) + { + // Browse the method's HasProperty children to find the InputArguments / + // OutputArguments property NodeIds. Standard browse-name-based lookup would also + // work but the property NodeIds aren't stable across servers, so we walk the + // references — the SDK gives us BrowseName + NodeId in the same response. + var browseDescriptions = new BrowseDescriptionCollection + { + new() + { + NodeId = methodNodeId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HasProperty, + IncludeSubtypes = true, + NodeClassMask = (uint)NodeClass.Variable, + ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.NodeClass), + } + }; + + NodeId? inputPropId = null; + NodeId? outputPropId = null; + try + { + var resp = await session.BrowseAsync( + requestHeader: null, + view: null, + requestedMaxReferencesPerNode: 0, + nodesToBrowse: browseDescriptions, + ct: ct).ConfigureAwait(false); + + if (resp.Results.Count == 0) return (null, null); + foreach (var rf in resp.Results[0].References) + { + var name = rf.BrowseName?.Name; + if (string.IsNullOrEmpty(name)) continue; + var propId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris); + if (NodeId.IsNull(propId)) continue; + if (string.Equals(name, BrowseNames.InputArguments, StringComparison.Ordinal)) + inputPropId = propId; + else if (string.Equals(name, BrowseNames.OutputArguments, StringComparison.Ordinal)) + outputPropId = propId; + } + } + catch + { + return (null, null); + } + + if (inputPropId is null && outputPropId is null) return (null, null); + + var nodesToRead = new ReadValueIdCollection(); + if (inputPropId is not null) + nodesToRead.Add(new ReadValueId { NodeId = inputPropId, AttributeId = Attributes.Value }); + if (outputPropId is not null) + nodesToRead.Add(new ReadValueId { NodeId = outputPropId, AttributeId = Attributes.Value }); + + DataValueCollection values; + try + { + var readResp = await session.ReadAsync( + requestHeader: null, + maxAge: 0, + timestampsToReturn: TimestampsToReturn.Neither, + nodesToRead: nodesToRead, + ct: ct).ConfigureAwait(false); + values = readResp.Results; + } + catch + { + return (null, null); + } + + var idx = 0; + IReadOnlyList? inputArgs = null; + IReadOnlyList? outputArgs = null; + if (inputPropId is not null) + { + inputArgs = ConvertArguments(values[idx++]); + } + if (outputPropId is not null) + { + outputArgs = ConvertArguments(values[idx]); + } + return (inputArgs, outputArgs); + } + + /// + /// Convert an OPC UA InputArguments/OutputArguments property value + /// (an array of Argument wrapped in ExtensionObject) into the local + /// DTO. Returns null when the value can't be + /// decoded — non-fatal, the method still registers without arg metadata. + /// + private static IReadOnlyList? ConvertArguments(DataValue dv) + { + if (StatusCode.IsBad(dv.StatusCode)) return null; + if (dv.Value is not ExtensionObject[] extensionArray) return null; + var result = new List(extensionArray.Length); + foreach (var ext in extensionArray) + { + if (ext?.Body is not Argument arg) continue; + result.Add(new MethodArgumentInfo( + Name: arg.Name ?? string.Empty, + DriverDataType: MapUpstreamDataType(arg.DataType), + ValueRank: arg.ValueRank, + Description: arg.Description?.Text)); + } + return result; + } + /// /// Render a NodeId as the canonical nsu=<uri>;… string, applying the /// configured upstream→local namespace-URI remap. Index-namespace nodes (ns=0, @@ -1917,6 +2061,103 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d finally { _gate.Release(); } } + // ---- IMethodInvoker ---- + + /// + /// Forward an OPC UA Call service invocation to the upstream server. The + /// method NodeId + object NodeId come from the address-space mirror set up during + /// discovery (); input values are CLR primitives + /// wrapped into s here so the cross-driver capability surface + /// stays SDK-free. + /// + /// + /// + /// Status-code passthrough: per driver-specs.md §8 (cascading quality), an + /// upstream Bad code is returned verbatim through + /// rather than thrown — downstream OPC UA clients see the canonical service result + /// (BadMethodInvalid, BadUserAccessDenied, BadArgumentsMissing, + /// …). Local-side faults (NodeId parse, lost session) surface the corresponding + /// StatusBad* constants the rest of the driver uses. + /// + /// + /// Per-argument validation results are unpacked into a uint[] so the + /// Core.Abstractions DTO stays SDK-free. null when the upstream + /// didn't surface per-argument codes (typical for Good calls). + /// + /// + public async Task CallMethodAsync( + string objectNodeId, string methodNodeId, object[] inputs, CancellationToken cancellationToken) + { + var session = RequireSession(); + + if (!TryParseNodeId(session, objectNodeId, out var objId)) + return new MethodCallResult(StatusBadNodeIdInvalid, null, null); + if (!TryParseNodeId(session, methodNodeId, out var methodId)) + return new MethodCallResult(StatusBadNodeIdInvalid, null, null); + + var inputVariants = new VariantCollection( + (inputs ?? Array.Empty()).Select(v => new Variant(v))); + + var callRequests = new CallMethodRequestCollection + { + new CallMethodRequest + { + ObjectId = objId, + MethodId = methodId, + InputArguments = inputVariants, + }, + }; + + CallMethodResultCollection results; + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + try + { + var resp = await session.CallAsync( + requestHeader: null, + methodsToCall: callRequests, + ct: cancellationToken).ConfigureAwait(false); + results = resp.Results; + } + catch + { + // Lost session / decode failure / cancellation. Surface a local + // BadCommunicationError so downstream clients can distinguish 'wire failed' + // from 'upstream rejected the call'. + return new MethodCallResult(StatusBadCommunicationError, null, null); + } + } + finally { _gate.Release(); } + + if (results.Count == 0) + return new MethodCallResult(StatusBadInternalError, null, null); + + var r = results[0]; + + // Unwrap output Variants into a CLR object[] so callers don't need an SDK dep. + object[]? outputs = null; + if (r.OutputArguments is { Count: > 0 }) + { + outputs = new object[r.OutputArguments.Count]; + for (var i = 0; i < r.OutputArguments.Count; i++) + outputs[i] = r.OutputArguments[i].Value!; + } + + // Per-input-argument validation results. Most servers return an empty list on + // success; only populate the DTO field when the server actually surfaced per-arg + // codes so callers can use null as 'no per-argument feedback'. + uint[]? inputArgResults = null; + if (r.InputArgumentResults is { Count: > 0 }) + { + inputArgResults = new uint[r.InputArgumentResults.Count]; + for (var i = 0; i < r.InputArgumentResults.Count; i++) + inputArgResults[i] = r.InputArgumentResults[i].Code; + } + + return new MethodCallResult(r.StatusCode.Code, outputs, inputArgResults); + } + private void OnEventNotification( OpcUaAlarmSubscriptionHandle handle, HashSet sourceFilter, diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMethodInvokerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMethodInvokerTests.cs new file mode 100644 index 0000000..686b850 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMethodInvokerTests.cs @@ -0,0 +1,181 @@ +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); + } +}