mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
ac8dc531ee
## Summary First of four stacked PRs that contribute complete implementations of the Machinery (OPC 40001-1 with Job Management, Result Transfer, Process Values and Energy), Pumps (OPC 40223) and Scales (OPC 40200, with PackML OPC 30050) companion specifications — tracked in #4576. It follows the split used for Robotics (#4123–#4127): this PR carries only the changes to **existing shared code** those SDKs need, and adds no project. Each change stands on its own and is covered by tests in this PR; the companion models are what surfaced them. Rebased onto master after #4569. The one conflict was in `NodeSetToModelDesign.cs`: #4569's shared `AssignSymbolicIds` already makes `Import` and `GetImportedSymbols` derive the same ids, and `NormalizeParentNodeId` moves into its pre-pass. The Copilot review is addressed in the last six commits (see "Review follow-up"). ## Changes ### Source generator (`tools/Opc.Ua.SourceGeneration.Core`) - **VariableType data type across assemblies.** A model that consumes a dependency through a referenced assembly's `[ModelDependency]` payload lost a VariableType's `DataType`/`ValueRank`, so `GetNodeStateClassName` dereferenced null and reported `MODELGEN003`. First hit: DI's `LifetimeVariableType` as used by Machinery. - **Method declaration ↔ method state identity.** A consumer re-declaring an inherited method referenced an `OwnerType_MethodMethodState` class the producer never generated. First hit: OPC 34100 ECM re-declaring the DI `LockingServices` methods. - **Browse names and reference types of inherited children.** An inherited child was emitted with `ReferenceTypeId = NodeId.Null` (invisible to a filtered Browse over the wire) and, where it lives in another namespace, with the declaring model's namespace. When the carried namespace is one the consuming model does not declare, the generated code names it by URI instead of emitting an empty `GetIndexOrAppend()`. - **Payload compatibility.** The three additions above travel in a length-prefixed, versioned declaration trailer after the existing trailers, so the node and child records keep their layout: an older reader ignores the capability bit and stops after the trailers it knows, and a payload without declarations is byte-identical to before. - **Symbolic ids of children listed before their parent.** Every `ParentNodeId` is normalized before any symbolic id is derived. No NodeSet in the repository has such an ordering today, so no generated symbol changes; OPC 30050 PackML (later in the stack) declares seven method `InputArguments` before their methods and failed with `MODELGEN025`. - **Methods overridden with another method-state class.** The subtype declares its own field and property for such a method, but `GetChildren` and `RemoveExplicitlyDefinedChild` skipped every overridden child and the supertype's field stays empty, so the method was missing from every instance (not browsed, no per-instance NodeId, not callable). The existing `MethodTypeOverride` test model has the gap; since #4555 generates a method with inline arguments as `MethodState`, OPC 40001-101 `ResultTransferType.GenerateFileForRead` hits it. It also affects models already in the repository: DI `SoftwareFolderType.Delete`, ISA-95 V2 `JobOrderReceiver.Update`, WoTCon `WoTDocument.Validate` and XRegistry `Group.Delete` / `Resource.Delete` now appear on their instances; the DI, ISA-95, WoTCon and XRegistry tests pass. ### ISA-95 Job Control (`src/Opc.Ua.ISA95.Server`) - **`Isa95JobControlV2Binder`** — the V2 receiver/response-provider binding moves out of `Isa95NodeManager` so OPC 40001-3 JobManagement can bind the same eleven verbs on its own instances instead of copying them. `Isa95NodeManager` behaviour is unchanged. - **`Isa95AddressSpaceOwnership`** — a second registration that would load the ISA-95 models fails immediately and names the current owner, as `DiAddressSpaceOwnership` already does for DI. - **`IIsa95JobResponseCatalog`** — a source for `JobOrderResponseList`, which the two state-filtered request methods cannot express. - **`IIsa95JobResponseChangeSource`** — a change stream of received responses, and of responses the provider drops after `ResponseRetention`; a response changes neither the order's state nor the order catalog, so the existing streams cannot report it. Both interfaces are implemented by `InMemoryIsa95JobControlProvider`, registered by `AddInMemoryIsa95JobControlProvider`, and count in `AddIsa95Server`'s provider detection and mixed-provider rejection. ### Device Integration (`src/Opc.Ua.Di.Server`, `src/Opc.Ua.Di.Client`) - **`IDiPostSetupContext.GetService<T>()`** — an optional lookup next to `GetRequiredService<T>()`, so a companion SDK can probe for a seam the server may not have filled without catching `InvalidOperationException`. The context still exposes no raw `IServiceProvider`. - **`DiNodeManager.ConformanceUnits` / `ServerProfiles` are virtual**, so a companion subclass appends the units its own model satisfies. - **DI client browse enumerators follow `BrowseNext`.** `DiTopologyClient.BrowseChildrenAsync` and `DiDeviceClient.BrowseFunctionalGroupsAsync` discarded the continuation point and returned an incomplete list from a paginating server. A bad status for the browsed node still means "no children" and a failed service call propagates; an outstanding continuation point is released whenever the enumeration stops early. ## Review follow-up | Copilot finding | Commit | |---|---| | Payload additions break older readers (both High) | `fix(sourcegen): move the dependency declarations into a trailer` | | Browse failures reported as empty, broad exception filter (Medium ×2); missing pagination tests (Low ×2) | `fix(di-client): let a failed browse call propagate` | | New provider facets missing from detection (Medium) | `fix(isa95): count the response facets as Job Control providers` | | Second review: continuation point kept after a failed BrowseNext | `fix(di-client): release the continuation point after a failed BrowseNext` | | Second review: purged responses not reported | `fix(isa95): report dropped job responses on the response change stream` | | Second review: missing V2 namespace cast to 65535 | `fix(isa95): reject a missing Job Control V2 namespace in the binder` | ## Points for the reviewer - `GetService<T>()` adds a member to a public 2.0 interface. The only implementation is the internal `DiPostSetupContext`; an external implementer would have to add it. - `DiDeviceClient.BrowseFunctionalGroupsAsync` used to throw for a bad status of the device node; like `DiTopologyClient` it now yields nothing. - The commits are self-contained and ordered; reviewing commit by commit is the easiest route. ## Testing - New and extended tests: `ModelDependencyV1Tests`, `NodeStateGeneratorTests`, `NodesetIdentifierSidecarTests`, `InMemoryIsa95JobControlProviderV2Tests`, `Isa95ServerBuilderTests`, `Isa95JobControlV2BinderTests`, `DiDeviceClientTests`, `DiTopologyClientTests`, `OpcUaServerDiBuilderExtensionsTests`. - `UA.slnx` builds for net472, net48, netstandard2.0/2.1 and net8/9/10 with SDK 10.0.401 and no warnings. - Tests on net10: SourceGeneration.Core 4266 (8 skipped), SourceGeneration 263, ISA95 144, DI 433; the first three also on net8. The changed projects build for net48 and netstandard2.0 without warnings. - Changed-line coverage (`check-coverage.ps1`): 91.1 % (594 of 652 lines). - The companion stack on top (#4578, #4579, #4580) is rebased onto this head. - Fork CI, full PR matrix incl. macOS, for this head (running): https://github.com/biancode/UA-.NETStandard/actions/runs/36558506011. CodeQL: https://github.com/biancode/UA-.NETStandard/actions/runs/36558509242. The previous head (before the second review round) passed every Windows and Linux job: https://github.com/biancode/UA-.NETStandard/actions/runs/36542929585.
532 lines
21 KiB
C#
532 lines
21 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 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.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Di.Client;
|
|
|
|
namespace Opc.Ua.Di.Tests
|
|
{
|
|
/// <summary>
|
|
/// Behavioural tests for <see cref="DiDeviceClient"/>. Covers
|
|
/// constructor validation, <see cref="DiDeviceClient.ForDeviceAsync"/>
|
|
/// existence check, and the full
|
|
/// <see cref="DiDeviceClient.ReadIdentificationAsync"/> translate +
|
|
/// batch read flow.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("DI")]
|
|
[Category("ClientHelpers")]
|
|
public sealed class DiDeviceClientTests
|
|
{
|
|
private const int IdentificationCount = 8;
|
|
|
|
[Test]
|
|
public void ConstructorThrowsOnNullSession()
|
|
{
|
|
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(
|
|
() => new DiDeviceClient(
|
|
null!, new NodeId("dev-1", 2), NullTelemetry()))!;
|
|
Assert.That(ex.ParamName, Is.EqualTo("session"));
|
|
}
|
|
|
|
[Test]
|
|
public void ConstructorThrowsOnNullDeviceNodeId()
|
|
{
|
|
ArgumentException ex = Assert.Throws<ArgumentException>(
|
|
() => new DiDeviceClient(
|
|
CreateSessionMock().Object, NodeId.Null, NullTelemetry()))!;
|
|
Assert.That(ex.ParamName, Is.EqualTo("deviceNodeId"));
|
|
}
|
|
|
|
[Test]
|
|
public void ConstructorThrowsOnNullTelemetry()
|
|
{
|
|
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(
|
|
() => new DiDeviceClient(
|
|
CreateSessionMock().Object, new NodeId("dev-1", 2), null!))!;
|
|
Assert.That(ex.ParamName, Is.EqualTo("telemetry"));
|
|
}
|
|
|
|
[Test]
|
|
public void ConstructorExposesArgumentsAndCreatesProxy()
|
|
{
|
|
ISession session = CreateSessionMock().Object;
|
|
var nodeId = new NodeId("dev-1", 2);
|
|
ITelemetryContext telemetry = NullTelemetry();
|
|
var client = new DiDeviceClient(session, nodeId, telemetry);
|
|
|
|
Assert.That(client.Session, Is.SameAs(session));
|
|
Assert.That(client.DeviceNodeId, Is.EqualTo(nodeId));
|
|
Assert.That(client.Telemetry, Is.SameAs(telemetry));
|
|
Assert.That(client.Proxy, Is.Not.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReadIdentificationAsyncBuildsEightBrowsePathsAndPopulatesValues()
|
|
{
|
|
Mock<ISession> sessionMock = CreateSessionMock();
|
|
|
|
ArrayOf<BrowsePath> capturedPaths = default;
|
|
sessionMock
|
|
.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
|
|
It.IsAny<RequestHeader?>(),
|
|
It.IsAny<ArrayOf<BrowsePath>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<RequestHeader?, ArrayOf<BrowsePath>, CancellationToken>(
|
|
(_, paths, _) => capturedPaths = paths)
|
|
.ReturnsAsync(() => new TranslateBrowsePathsToNodeIdsResponse
|
|
{
|
|
Results = BuildAllGoodBrowsePathResults(IdentificationCount)
|
|
});
|
|
|
|
SetupReadReturns(sessionMock, BuildGoodValues(IdentificationCount));
|
|
|
|
var client = new DiDeviceClient(
|
|
sessionMock.Object, new NodeId("dev-1", 2), NullTelemetry());
|
|
|
|
DeviceIdentification id = await client.ReadIdentificationAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(capturedPaths.Count, Is.EqualTo(IdentificationCount));
|
|
Assert.That(id.Manufacturer, Is.EqualTo("v0"));
|
|
Assert.That(id.Model, Is.EqualTo("v1"));
|
|
Assert.That(id.SerialNumber, Is.EqualTo("v2"));
|
|
Assert.That(id.HardwareRevision, Is.EqualTo("v3"));
|
|
Assert.That(id.SoftwareRevision, Is.EqualTo("v4"));
|
|
Assert.That(id.DeviceRevision, Is.EqualTo("v5"));
|
|
Assert.That(id.DeviceClass, Is.EqualTo("v6"));
|
|
Assert.That(id.ProductInstanceUri, Is.EqualTo("v7"));
|
|
|
|
sessionMock.Verify(
|
|
s => s.ReadAsync(
|
|
It.IsAny<RequestHeader?>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReadIdentificationAsyncBadValueReadIsNull()
|
|
{
|
|
Mock<ISession> sessionMock = CreateSessionMock();
|
|
SetupTranslateReturns(
|
|
sessionMock, BuildAllGoodBrowsePathResults(IdentificationCount));
|
|
|
|
// Read 3rd value with bad status — SerialNumber should be null.
|
|
DataValue[] values = BuildGoodValues(IdentificationCount);
|
|
values[2] = new DataValue(Variant.Null
|
|
, StatusCodes.BadAttributeIdInvalid);
|
|
SetupReadReturns(sessionMock, values);
|
|
|
|
var client = new DiDeviceClient(
|
|
sessionMock.Object, new NodeId("dev-1", 2), NullTelemetry());
|
|
|
|
DeviceIdentification id = await client.ReadIdentificationAsync().ConfigureAwait(false);
|
|
Assert.That(id.SerialNumber, Is.Null);
|
|
Assert.That(id.Manufacturer, Is.EqualTo("v0"));
|
|
Assert.That(id.Model, Is.EqualTo("v1"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReadIdentificationAsyncReturnsAllNullsWhenAllBrowsePathsBad()
|
|
{
|
|
Mock<ISession> sessionMock = CreateSessionMock();
|
|
|
|
var badResults = new BrowsePathResult[IdentificationCount];
|
|
for (int i = 0; i < IdentificationCount; i++)
|
|
{
|
|
badResults[i] = new BrowsePathResult
|
|
{
|
|
StatusCode = StatusCodes.BadNoMatch,
|
|
Targets = global::Opc.Ua.ArrayOf.Empty<BrowsePathTarget>()
|
|
};
|
|
}
|
|
SetupTranslateReturns(sessionMock, badResults);
|
|
|
|
// ReadAsync should not be called when no paths resolved; set up
|
|
// a strict expectation to verify that.
|
|
var client = new DiDeviceClient(
|
|
sessionMock.Object, new NodeId("dev-1", 2), NullTelemetry());
|
|
|
|
DeviceIdentification id = await client.ReadIdentificationAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(id.Manufacturer, Is.Null);
|
|
Assert.That(id.Model, Is.Null);
|
|
Assert.That(id.SerialNumber, Is.Null);
|
|
Assert.That(id.HardwareRevision, Is.Null);
|
|
Assert.That(id.SoftwareRevision, Is.Null);
|
|
Assert.That(id.DeviceRevision, Is.Null);
|
|
Assert.That(id.DeviceClass, Is.Null);
|
|
Assert.That(id.ProductInstanceUri, Is.Null);
|
|
|
|
sessionMock.Verify(
|
|
s => s.ReadAsync(
|
|
It.IsAny<RequestHeader?>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Never);
|
|
}
|
|
|
|
[Test]
|
|
public void ForDeviceAsyncThrowsBadNodeIdUnknownWhenReadStatusBad()
|
|
{
|
|
Mock<ISession> sessionMock = CreateSessionMock();
|
|
SetupReadReturns(sessionMock,
|
|
[
|
|
new DataValue(Variant.Null
|
|
, StatusCodes.BadNodeIdUnknown)
|
|
]);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await DiDeviceClient.ForDeviceAsync(
|
|
sessionMock.Object,
|
|
new NodeId("dev-1", 2),
|
|
NullTelemetry()).ConfigureAwait(false))!;
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown));
|
|
}
|
|
|
|
[Test]
|
|
public async Task ForDeviceAsyncReturnsClientWhenReadSucceeds()
|
|
{
|
|
Mock<ISession> sessionMock = CreateSessionMock();
|
|
SetupReadReturns(sessionMock,
|
|
[
|
|
new DataValue(new Variant((int)NodeClass.Object)
|
|
, StatusCodes.Good)
|
|
]);
|
|
|
|
DiDeviceClient client = await DiDeviceClient.ForDeviceAsync(
|
|
sessionMock.Object,
|
|
new NodeId("dev-1", 2),
|
|
NullTelemetry()).ConfigureAwait(false);
|
|
|
|
Assert.That(client, Is.Not.Null);
|
|
Assert.That(client.DeviceNodeId, Is.EqualTo(new NodeId("dev-1", 2)));
|
|
}
|
|
|
|
[Test]
|
|
public async Task BrowseFunctionalGroupsAsyncReturnsSubtypeInstancesAsync()
|
|
{
|
|
Mock<ISession> sessionMock = CreateSessionMock();
|
|
var nodeCacheMock = new Mock<INodeCache>(MockBehavior.Strict);
|
|
sessionMock.SetupGet(s => s.NodeCache).Returns(nodeCacheMock.Object);
|
|
|
|
ExpandedNodeId functionalGroupSubtype = new("VendorFunctionalGroupType", 2);
|
|
ExpandedNodeId unrelatedType = new("FolderType", 2);
|
|
ReferenceDescription subtypeGroup = new()
|
|
{
|
|
NodeId = new ExpandedNodeId(new NodeId("group-1", 2)),
|
|
DisplayName = new LocalizedText("VendorGroup"),
|
|
TypeDefinition = functionalGroupSubtype,
|
|
NodeClass = NodeClass.Object
|
|
};
|
|
ReferenceDescription folder = new()
|
|
{
|
|
NodeId = new ExpandedNodeId(new NodeId("folder-1", 2)),
|
|
DisplayName = new LocalizedText("Folder"),
|
|
TypeDefinition = unrelatedType,
|
|
NodeClass = NodeClass.Object
|
|
};
|
|
|
|
sessionMock
|
|
.Setup(s => s.BrowseAsync(
|
|
It.IsAny<RequestHeader?>(),
|
|
It.IsAny<ViewDescription?>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<ArrayOf<BrowseDescription>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new BrowseResponse
|
|
{
|
|
Results =
|
|
[
|
|
new BrowseResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
References = [subtypeGroup, folder]
|
|
}
|
|
]
|
|
});
|
|
|
|
nodeCacheMock
|
|
.Setup(c => c.IsTypeOfAsync(
|
|
functionalGroupSubtype,
|
|
Opc.Ua.Di.ObjectTypeIds.FunctionalGroupType,
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<bool>(true));
|
|
nodeCacheMock
|
|
.Setup(c => c.IsTypeOfAsync(
|
|
unrelatedType,
|
|
Opc.Ua.Di.ObjectTypeIds.FunctionalGroupType,
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<bool>(false));
|
|
|
|
var client = new DiDeviceClient(
|
|
sessionMock.Object, new NodeId("dev-1", 2), NullTelemetry());
|
|
|
|
var groups = new System.Collections.Generic.List<FunctionalGroupEntry>();
|
|
await foreach (FunctionalGroupEntry group in client.BrowseFunctionalGroupsAsync())
|
|
{
|
|
groups.Add(group);
|
|
}
|
|
|
|
Assert.That(groups, Has.Count.EqualTo(1));
|
|
Assert.That(groups[0].NodeId, Is.EqualTo(new NodeId("group-1", 2)));
|
|
Assert.That(groups[0].DisplayName, Is.EqualTo("VendorGroup"));
|
|
nodeCacheMock.VerifyAll();
|
|
}
|
|
|
|
[Test]
|
|
public async Task BrowseFunctionalGroupsAsyncFollowsTheContinuationPointAsync()
|
|
{
|
|
Mock<ISession> sessionMock = CreateSessionMock();
|
|
var nodeCacheMock = new Mock<INodeCache>();
|
|
sessionMock.SetupGet(s => s.NodeCache).Returns(nodeCacheMock.Object);
|
|
nodeCacheMock
|
|
.Setup(c => c.IsTypeOfAsync(
|
|
It.IsAny<ExpandedNodeId>(),
|
|
Opc.Ua.Di.ObjectTypeIds.FunctionalGroupType,
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<bool>(true));
|
|
var continuationPoint = new ByteString(new byte[] { 7 });
|
|
SetupFunctionalGroupBrowse(sessionMock, new BrowseResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint = continuationPoint,
|
|
References = [MakeFunctionalGroup("group-1")]
|
|
});
|
|
sessionMock
|
|
.Setup(s => s.BrowseNextAsync(
|
|
It.IsAny<RequestHeader?>(),
|
|
false,
|
|
It.Is<ArrayOf<ByteString>>(points => points.Count == 1 && points[0] == continuationPoint),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new BrowseNextResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results =
|
|
[
|
|
new BrowseResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
References = [MakeFunctionalGroup("group-2")]
|
|
}
|
|
]
|
|
});
|
|
|
|
var client = new DiDeviceClient(
|
|
sessionMock.Object, new NodeId("dev-1", 2), NullTelemetry());
|
|
var groups = new System.Collections.Generic.List<FunctionalGroupEntry>();
|
|
await foreach (FunctionalGroupEntry group in client.BrowseFunctionalGroupsAsync())
|
|
{
|
|
groups.Add(group);
|
|
}
|
|
|
|
Assert.That(
|
|
groups.Select(group => group.NodeId),
|
|
Is.EqualTo(new[] { new NodeId("group-1", 2), new NodeId("group-2", 2) }));
|
|
}
|
|
|
|
[Test]
|
|
public async Task BrowseFunctionalGroupsAsyncIsEmptyForAnUnbrowsableDeviceAsync()
|
|
{
|
|
Mock<ISession> sessionMock = CreateSessionMock();
|
|
SetupFunctionalGroupBrowse(sessionMock, new BrowseResult
|
|
{
|
|
StatusCode = StatusCodes.BadNodeIdUnknown
|
|
});
|
|
|
|
var client = new DiDeviceClient(
|
|
sessionMock.Object, new NodeId("dev-1", 2), NullTelemetry());
|
|
var groups = new System.Collections.Generic.List<FunctionalGroupEntry>();
|
|
await foreach (FunctionalGroupEntry group in client.BrowseFunctionalGroupsAsync())
|
|
{
|
|
groups.Add(group);
|
|
}
|
|
|
|
Assert.That(groups, Is.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public void BrowseFunctionalGroupsAsyncPropagatesAFailedBrowseCall()
|
|
{
|
|
Mock<ISession> sessionMock = CreateSessionMock();
|
|
sessionMock
|
|
.Setup(s => s.BrowseAsync(
|
|
It.IsAny<RequestHeader?>(),
|
|
It.IsAny<ViewDescription?>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<ArrayOf<BrowseDescription>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadSessionIdInvalid));
|
|
|
|
var client = new DiDeviceClient(
|
|
sessionMock.Object, new NodeId("dev-1", 2), NullTelemetry());
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
{
|
|
await foreach (FunctionalGroupEntry _ in client.BrowseFunctionalGroupsAsync())
|
|
{
|
|
}
|
|
})!;
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
|
}
|
|
|
|
private static ReferenceDescription MakeFunctionalGroup(string id)
|
|
{
|
|
return new ReferenceDescription
|
|
{
|
|
NodeId = new ExpandedNodeId(new NodeId(id, 2)),
|
|
DisplayName = new LocalizedText(id),
|
|
TypeDefinition = new ExpandedNodeId("VendorFunctionalGroupType", 2),
|
|
NodeClass = NodeClass.Object
|
|
};
|
|
}
|
|
|
|
private static void SetupFunctionalGroupBrowse(
|
|
Mock<ISession> sessionMock,
|
|
BrowseResult result)
|
|
{
|
|
sessionMock
|
|
.Setup(s => s.BrowseAsync(
|
|
It.IsAny<RequestHeader?>(),
|
|
It.IsAny<ViewDescription?>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<ArrayOf<BrowseDescription>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new BrowseResponse
|
|
{
|
|
Results = [result]
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ForDeviceAsyncThrowsOnNullSession()
|
|
{
|
|
ArgumentNullException ex = Assert.ThrowsAsync<ArgumentNullException>(
|
|
async () => await DiDeviceClient.ForDeviceAsync(
|
|
null!, new NodeId("dev-1", 2), NullTelemetry()).ConfigureAwait(false))!;
|
|
Assert.That(ex.ParamName, Is.EqualTo("session"));
|
|
}
|
|
|
|
[Test]
|
|
public void ForDeviceAsyncThrowsOnNullDeviceNodeId()
|
|
{
|
|
ISession session = CreateSessionMock().Object;
|
|
ArgumentException ex = Assert.ThrowsAsync<ArgumentException>(
|
|
async () => await DiDeviceClient.ForDeviceAsync(
|
|
session, NodeId.Null, NullTelemetry()).ConfigureAwait(false))!;
|
|
Assert.That(ex.ParamName, Is.EqualTo("deviceNodeId"));
|
|
}
|
|
|
|
private static BrowsePathResult[] BuildAllGoodBrowsePathResults(int count)
|
|
{
|
|
var results = new BrowsePathResult[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
results[i] = new BrowsePathResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
Targets = new BrowsePathTarget[]
|
|
{
|
|
new() {
|
|
TargetId = new ExpandedNodeId("prop-" + i, 2),
|
|
RemainingPathIndex = uint.MaxValue
|
|
}
|
|
}
|
|
};
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private static DataValue[] BuildGoodValues(int count)
|
|
{
|
|
var values = new DataValue[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
values[i] = new DataValue(new Variant("v" + i)
|
|
, StatusCodes.Good);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
private static Mock<ISession> CreateSessionMock()
|
|
{
|
|
var mock = new Mock<ISession>();
|
|
var nsTable = new NamespaceTable();
|
|
nsTable.GetIndexOrAppend(global::Opc.Ua.Di.Namespaces.OpcUaDi);
|
|
mock.SetupGet(s => s.NamespaceUris).Returns(nsTable);
|
|
return mock;
|
|
}
|
|
|
|
private static void SetupTranslateReturns(
|
|
Mock<ISession> sessionMock, BrowsePathResult[] results)
|
|
{
|
|
sessionMock
|
|
.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
|
|
It.IsAny<RequestHeader?>(),
|
|
It.IsAny<ArrayOf<BrowsePath>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TranslateBrowsePathsToNodeIdsResponse
|
|
{
|
|
Results = results
|
|
});
|
|
}
|
|
|
|
private static void SetupReadReturns(
|
|
Mock<ISession> sessionMock, DataValue[] results)
|
|
{
|
|
sessionMock
|
|
.Setup(s => s.ReadAsync(
|
|
It.IsAny<RequestHeader?>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new ReadResponse
|
|
{
|
|
Results = results
|
|
});
|
|
}
|
|
|
|
private static ITelemetryContext NullTelemetry()
|
|
{
|
|
return new Mock<ITelemetryContext>().Object;
|
|
}
|
|
}
|
|
}
|