Files
Klaus Landsdorf ac8dc531ee Source generator, ISA-95 Job Control and DI changes for companion SDKs built on referenced models (#4577)
## 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.
2026-09-29 14:50:00 +02:00

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