feat(dcl): BrowseNext continuation paging + StubOpcUaClient canned browse (T15)
This commit is contained in:
@@ -10,21 +10,28 @@ public interface IBrowsableDataConnection
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the immediate children of <paramref name="parentNodeId"/>, or
|
||||
/// the server's root-level nodes when null.
|
||||
/// the server's root-level nodes when null. Pageable: when a prior call
|
||||
/// returned a non-null <see cref="BrowseChildrenResult.ContinuationToken"/>,
|
||||
/// pass it back via <paramref name="continuationToken"/> to fetch the next
|
||||
/// page; a null/empty token starts a fresh browse of <paramref name="parentNodeId"/>.
|
||||
/// </summary>
|
||||
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root (OPC UA ObjectsFolder).</param>
|
||||
/// <param name="continuationToken">Opaque token from a prior <see cref="BrowseChildrenResult.ContinuationToken"/> to fetch the next page; null/empty starts a fresh browse. The token is implementation-private (e.g. a Base64 OPC UA continuation point) and must not be interpreted by callers.</param>
|
||||
/// <param name="cancellationToken">Cancellation token; on cancellation the implementation should throw <see cref="OperationCanceledException"/>.</param>
|
||||
/// <returns>A task that resolves to the child nodes and a flag indicating whether results were truncated.</returns>
|
||||
/// <returns>A task that resolves to the child nodes, a flag indicating whether more results remain, and a continuation token to fetch the next page (null when exhausted).</returns>
|
||||
Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <param name="Children">Child nodes returned by the server in browse order.</param>
|
||||
/// <param name="Truncated">True when the server reported more children than the per-call cap; remaining children must be discovered via manual entry.</param>
|
||||
/// <param name="Truncated">True when more children remain beyond this page; fetch them by passing <paramref name="ContinuationToken"/> back to <see cref="IBrowsableDataConnection.BrowseChildrenAsync"/>.</param>
|
||||
/// <param name="ContinuationToken">Opaque token to fetch the next page (echo it back via the <c>continuationToken</c> parameter), or null when the browse is exhausted. Implementation-private — callers must treat it as opaque.</param>
|
||||
public record BrowseChildrenResult(
|
||||
IReadOnlyList<BrowseNode> Children,
|
||||
bool Truncated);
|
||||
bool Truncated,
|
||||
string? ContinuationToken = null);
|
||||
|
||||
/// <param name="NodeId">Server-issued node identifier (e.g. <c>"ns=2;s=Devices.Pump1.Speed"</c>).</param>
|
||||
/// <param name="DisplayName">Human-readable display name from the server's DisplayName attribute.</param>
|
||||
|
||||
@@ -132,15 +132,21 @@ public interface IOpcUaClient : IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the immediate children of <paramref name="parentNodeId"/>
|
||||
/// (or the server's ObjectsFolder when null). Throws
|
||||
/// (or the server's ObjectsFolder when null). Pageable via the OPC UA
|
||||
/// continuation-point mechanism: when a prior call returned a non-null
|
||||
/// <see cref="BrowseChildrenResult.ContinuationToken"/>, pass it back via
|
||||
/// <paramref name="continuationToken"/> to fetch the next page (BrowseNext);
|
||||
/// a null/empty token starts a fresh browse. Throws
|
||||
/// <see cref="ConnectionNotConnectedException"/> when the session is not
|
||||
/// currently up.
|
||||
/// </summary>
|
||||
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root.</param>
|
||||
/// <param name="continuationToken">Opaque token from a prior <see cref="BrowseChildrenResult.ContinuationToken"/> to fetch the next page via BrowseNext; null/empty starts a fresh browse.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A task that completes with the immediate children of the requested node.</returns>
|
||||
/// <returns>A task that completes with the immediate children of the requested node and a continuation token for the next page (null when exhausted).</returns>
|
||||
Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -235,8 +241,48 @@ internal class StubOpcUaClient : IOpcUaClient
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Canned address-space tree so DCL browse/paging flows can be exercised
|
||||
// without a live OPC UA server (T15):
|
||||
// (root)
|
||||
// ├─ Folder1 (Object, HasChildren=true)
|
||||
// │ ├─ Tag1 (Variable) ← page 1
|
||||
// │ └─ Tag2 (Variable) ← page 2 (continuation token "STUB_PAGE2")
|
||||
// └─ Folder2 (Object, HasChildren=false, leaf)
|
||||
// Folder1 fakes BrowseNext continuation paging so the continuation-token
|
||||
// round-trip is testable; everything else is a single, exhausted page.
|
||||
if (string.IsNullOrEmpty(parentNodeId))
|
||||
{
|
||||
return Task.FromResult(new BrowseChildrenResult(
|
||||
new[]
|
||||
{
|
||||
new BrowseNode("Folder1", "Folder1", BrowseNodeClass.Object, HasChildren: true),
|
||||
new BrowseNode("Folder2", "Folder2", BrowseNodeClass.Object, HasChildren: false),
|
||||
},
|
||||
Truncated: false));
|
||||
}
|
||||
|
||||
if (parentNodeId == "Folder1")
|
||||
{
|
||||
// Page 2: the caller passed back the continuation token from page 1.
|
||||
if (continuationToken == "STUB_PAGE2")
|
||||
{
|
||||
return Task.FromResult(new BrowseChildrenResult(
|
||||
new[] { new BrowseNode("Tag2", "Tag2", BrowseNodeClass.Variable, HasChildren: false) },
|
||||
Truncated: false));
|
||||
}
|
||||
|
||||
// Page 1: return only Tag1 and a continuation token for the next page.
|
||||
return Task.FromResult(new BrowseChildrenResult(
|
||||
new[] { new BrowseNode("Tag1", "Tag1", BrowseNodeClass.Variable, HasChildren: false) },
|
||||
Truncated: true,
|
||||
ContinuationToken: "STUB_PAGE2"));
|
||||
}
|
||||
|
||||
// Leaves (Folder2, Variable nodes, anything unknown) have no children.
|
||||
return Task.FromResult(new BrowseChildrenResult(Array.Empty<BrowseNode>(), Truncated: false));
|
||||
}
|
||||
|
||||
/// <summary>Disposes this stub client and marks the connection as closed.</summary>
|
||||
/// <returns>A completed <see cref="ValueTask"/>.</returns>
|
||||
|
||||
@@ -254,11 +254,17 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BrowseChildrenResult> BrowseChildrenAsync(string? parentNodeId, CancellationToken cancellationToken = default)
|
||||
public async Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_status != ConnectionHealth.Connected || _client is null)
|
||||
throw new ConnectionNotConnectedException($"MxGateway connection is not connected (status: {_status}).");
|
||||
|
||||
// MxGateway browse is not pageable — the gateway returns a single,
|
||||
// already-bounded child set per node. Any continuation token is ignored
|
||||
// and no continuation is ever surfaced (ContinuationToken stays null).
|
||||
var (children, truncated) = await _client.BrowseChildrenAsync(parentNodeId, cancellationToken);
|
||||
var nodes = children
|
||||
.Select(c => new BrowseNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
|
||||
|
||||
@@ -298,8 +298,9 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
|
||||
/// <inheritdoc />
|
||||
public Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _client!.BrowseChildrenAsync(parentNodeId, cancellationToken);
|
||||
=> _client!.BrowseChildrenAsync(parentNodeId, continuationToken, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> WriteBatchAndWaitAsync(
|
||||
|
||||
@@ -701,9 +701,22 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
|
||||
: configured;
|
||||
|
||||
// requestedMaxReferencesPerNode: cap the server's per-call references so a
|
||||
// huge flat folder cannot return an unbounded set. 500 leaves headroom for
|
||||
// the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
|
||||
// even with long string NodeIds; a non-empty continuation point surfaces as
|
||||
// a ContinuationToken so the caller can page via BrowseNext (T15).
|
||||
private const uint BrowseMaxReferencesPerNode = 500u;
|
||||
|
||||
// NodeClassMask intentionally excludes ReferenceType, View, Variable-
|
||||
// Type, ObjectType, DataType. UI only needs Objects (navigable),
|
||||
// Variables (selectable), Methods (display-only).
|
||||
private const uint BrowseNodeClassMask =
|
||||
(uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId, CancellationToken cancellationToken = default)
|
||||
string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Mirror the SubscribeAsync/ReadAsync wrap idiom: snapshot the session
|
||||
// reference once, fail fast with a typed exception if the link is
|
||||
@@ -723,27 +736,83 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
? ObjectIds.ObjectsFolder
|
||||
: NodeId.Parse(parentNodeId);
|
||||
|
||||
// NodeClassMask intentionally excludes ReferenceType, View, Variable-
|
||||
// Type, ObjectType, DataType. UI only needs Objects (navigable),
|
||||
// Variables (selectable), Methods (display-only).
|
||||
var nodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
|
||||
// No token → fresh browse of the node. A non-empty token → continue a
|
||||
// prior browse via BrowseNext, falling back to a fresh browse if the
|
||||
// server has invalidated the continuation point.
|
||||
if (string.IsNullOrEmpty(continuationToken))
|
||||
{
|
||||
return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// requestedMaxReferencesPerNode: cap the server's per-call references so a
|
||||
// huge flat folder cannot return an unbounded set. 500 leaves headroom for
|
||||
// the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
|
||||
// even with long string NodeIds; a non-empty continuation point surfaces as
|
||||
// Truncated, prompting manual entry rather than auto-paging.
|
||||
try
|
||||
{
|
||||
// SDK overload: BrowseNextAsync(session, requestHeader, ByteStringCollection
|
||||
// continuationPoints, bool releaseContinuationPoint, ct) → returns
|
||||
// (ResponseHeader, ByteStringCollection revisedContinuationPoints,
|
||||
// IList<ReferenceDescriptionCollection> results, IList<ServiceResult> errors).
|
||||
// releaseContinuationPoint:false keeps the point alive so the next page
|
||||
// can be fetched; we pass back exactly the one point we were handed.
|
||||
var (_, revisedPoints, results, _) = await session.BrowseNextAsync(
|
||||
null,
|
||||
new ByteStringCollection { Convert.FromBase64String(continuationToken) },
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var nextPoint = revisedPoints is { Count: > 0 } ? revisedPoints[0] : null;
|
||||
var refs = results is { Count: > 0 } ? results[0] : null;
|
||||
return await BuildResultAsync(session, refs, nextPoint, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ServiceResultException ex) when (
|
||||
ex.StatusCode == StatusCodes.BadContinuationPointInvalid ||
|
||||
ex.StatusCode == StatusCodes.BadInvalidArgument)
|
||||
{
|
||||
// The continuation point expired or was rejected (e.g. a fresh
|
||||
// session, or the server timed it out). Recover by re-browsing the
|
||||
// parent from the start and returning its first page.
|
||||
_logger.LogDebug(ex,
|
||||
"OPC UA BrowseNext rejected the continuation point (status {Status:X8}); " +
|
||||
"falling back to a fresh browse of {Node}.", ex.StatusCode, nodeToBrowse);
|
||||
return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh <see cref="SessionClientExtensions.BrowseAsync"/> of
|
||||
/// <paramref name="nodeToBrowse"/> and returns its first page, surfacing any
|
||||
/// continuation point as a token for paging.
|
||||
/// </summary>
|
||||
private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> FreshBrowseAsync(
|
||||
ISession session, NodeId nodeToBrowse, CancellationToken cancellationToken)
|
||||
{
|
||||
var (_, continuationPoint, references) = await session.BrowseAsync(
|
||||
null,
|
||||
null,
|
||||
nodeToBrowse,
|
||||
500u,
|
||||
BrowseMaxReferencesPerNode,
|
||||
BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences,
|
||||
true,
|
||||
nodeClassMask,
|
||||
BrowseNodeClassMask,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await BuildResultAsync(session, references, continuationPoint, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared "build children + B1 type enrichment + continuation token" logic
|
||||
/// used by both the initial browse and the BrowseNext paths so the
|
||||
/// enrichment is never duplicated (T15). A non-empty
|
||||
/// <paramref name="continuationPoint"/> is surfaced as a Base64
|
||||
/// <c>ContinuationToken</c> with <c>Truncated=true</c>; otherwise the result
|
||||
/// is exhausted (<c>ContinuationToken=null, Truncated=false</c>).
|
||||
/// </summary>
|
||||
private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BuildResultAsync(
|
||||
ISession session,
|
||||
ReferenceDescriptionCollection? references,
|
||||
byte[]? continuationPoint,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var refs = references ?? new ReferenceDescriptionCollection();
|
||||
var children = new List<Commons.Interfaces.Protocol.BrowseNode>(refs.Count);
|
||||
foreach (var r in refs)
|
||||
@@ -755,22 +824,22 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
HasChildren: r.NodeClass == NodeClass.Object));
|
||||
}
|
||||
|
||||
// T16 type-info: enrich Variable rows with DataType / ValueRank /
|
||||
// B1 type-info: enrich Variable rows with DataType / ValueRank /
|
||||
// Writable so the node picker can show types. Best-effort: ONE batched
|
||||
// ReadAsync over (DataType, ValueRank, UserAccessLevel) for every
|
||||
// Variable child; on ANY failure we leave the three fields null and
|
||||
// return the children exactly as built above. Non-Variable nodes are
|
||||
// never read and keep null type info.
|
||||
// never read and keep null type info. Runs identically on the browse
|
||||
// and browse-next pages (T15 shares this path).
|
||||
children = await EnrichVariableTypeInfoAsync(session, refs, children, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// A non-empty continuation point means the server had more refs than
|
||||
// our requestedMaxReferencesPerNode cap. The UI surfaces a "more
|
||||
// children, type the node id manually" hint rather than auto-paging;
|
||||
// BrowseNext is not invoked here. Discarding the continuation point
|
||||
// is acceptable because the server expires it on session close.
|
||||
var truncated = continuationPoint != null && continuationPoint.Length > 0;
|
||||
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, truncated);
|
||||
// A non-empty continuation point means the server has more refs than
|
||||
// were returned on this page. Surface it as an opaque Base64 token the
|
||||
// caller passes back to fetch the next page via BrowseNext (T15).
|
||||
var hasMore = continuationPoint != null && continuationPoint.Length > 0;
|
||||
var token = hasMore ? Convert.ToBase64String(continuationPoint!) : null;
|
||||
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, hasMore, token);
|
||||
}
|
||||
|
||||
private static Commons.Interfaces.Protocol.BrowseNodeClass MapNodeClass(NodeClass nc) => nc switch
|
||||
|
||||
+4
-4
@@ -105,7 +105,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
|
||||
new BrowseNode("ns=2;s=B", "B", BrowseNodeClass.Object, HasChildren: true),
|
||||
};
|
||||
((IBrowsableDataConnection)adapter)
|
||||
.BrowseChildrenAsync(null, Arg.Any<CancellationToken>())
|
||||
.BrowseChildrenAsync(null, Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new BrowseChildrenResult(children, Truncated: false));
|
||||
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
||||
@@ -139,7 +139,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
|
||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||
|
||||
((IBrowsableDataConnection)adapter)
|
||||
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<BrowseChildrenResult>(
|
||||
new ConnectionNotConnectedException("OPC UA session is not connected.")));
|
||||
|
||||
@@ -183,7 +183,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
|
||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||
|
||||
((IBrowsableDataConnection)adapter)
|
||||
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<BrowseChildrenResult>(new NotSupportedException(reason)));
|
||||
|
||||
_factory.Create("MxGateway", Arg.Any<IDictionary<string, string>>())
|
||||
@@ -226,7 +226,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
|
||||
.Returns(Task.CompletedTask);
|
||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||
((IBrowsableDataConnection)adapter)
|
||||
.BrowseChildrenAsync(null, Arg.Any<CancellationToken>())
|
||||
.BrowseChildrenAsync(null, Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new BrowseChildrenResult(bigList, Truncated: false));
|
||||
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ public class OpcUaDataConnectionBrowseTests
|
||||
var expected = new BrowseChildrenResult(
|
||||
new[] { new BrowseNode("ns=2;s=X", "X", BrowseNodeClass.Variable, false) },
|
||||
Truncated: false);
|
||||
client.BrowseChildrenAsync("ns=2;s=Parent", Arg.Any<CancellationToken>())
|
||||
client.BrowseChildrenAsync("ns=2;s=Parent", Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(expected);
|
||||
|
||||
var adapter = new OpcUaDataConnection(factory, NullLogger<OpcUaDataConnection>.Instance);
|
||||
@@ -36,6 +36,6 @@ public class OpcUaDataConnectionBrowseTests
|
||||
var actual = await adapter.BrowseChildrenAsync("ns=2;s=Parent");
|
||||
|
||||
Assert.Same(expected, actual);
|
||||
await client.Received(1).BrowseChildrenAsync("ns=2;s=Parent", Arg.Any<CancellationToken>());
|
||||
await client.Received(1).BrowseChildrenAsync("ns=2;s=Parent", Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// M7-B2 (T15): <see cref="StubOpcUaClient"/> serves a small canned address-space
|
||||
/// tree so DCL browse/paging flows can be exercised without a live OPC UA server.
|
||||
/// The stub also fakes BrowseNext continuation paging on <c>Folder1</c> so the
|
||||
/// continuation-token round-trip is testable end-to-end against the contract.
|
||||
/// </summary>
|
||||
public class StubOpcUaClientBrowseTests
|
||||
{
|
||||
// StubOpcUaClient is internal; reached here via InternalsVisibleTo on the
|
||||
// DataConnectionLayer assembly (the test project is already a friend, since
|
||||
// other tests in this project new up internal adapter types).
|
||||
private static async Task<IOpcUaClient> ConnectedStubAsync()
|
||||
{
|
||||
var client = new StubOpcUaClient();
|
||||
await client.ConnectAsync("opc.tcp://stub", null, CancellationToken.None);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseRoot_ReturnsTwoFolders_NoContinuation()
|
||||
{
|
||||
await using var client = (StubOpcUaClient)await ConnectedStubAsync();
|
||||
|
||||
var result = await client.BrowseChildrenAsync(parentNodeId: null);
|
||||
|
||||
Assert.False(result.Truncated);
|
||||
Assert.Null(result.ContinuationToken);
|
||||
Assert.Collection(
|
||||
result.Children,
|
||||
n =>
|
||||
{
|
||||
Assert.Equal("Folder1", n.DisplayName);
|
||||
Assert.Equal(BrowseNodeClass.Object, n.NodeClass);
|
||||
Assert.True(n.HasChildren);
|
||||
},
|
||||
n =>
|
||||
{
|
||||
Assert.Equal("Folder2", n.DisplayName);
|
||||
Assert.Equal(BrowseNodeClass.Object, n.NodeClass);
|
||||
Assert.False(n.HasChildren);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseFolder1_NoToken_ReturnsTag1_AndContinuationToken()
|
||||
{
|
||||
await using var client = (StubOpcUaClient)await ConnectedStubAsync();
|
||||
|
||||
var result = await client.BrowseChildrenAsync(parentNodeId: "Folder1");
|
||||
|
||||
Assert.True(result.Truncated);
|
||||
Assert.Equal("STUB_PAGE2", result.ContinuationToken);
|
||||
var only = Assert.Single(result.Children);
|
||||
Assert.Equal("Tag1", only.DisplayName);
|
||||
Assert.Equal(BrowseNodeClass.Variable, only.NodeClass);
|
||||
Assert.False(only.HasChildren);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseFolder1_WithContinuationToken_ReturnsTag2_AndNoFurtherContinuation()
|
||||
{
|
||||
await using var client = (StubOpcUaClient)await ConnectedStubAsync();
|
||||
|
||||
var result = await client.BrowseChildrenAsync(
|
||||
parentNodeId: "Folder1",
|
||||
continuationToken: "STUB_PAGE2");
|
||||
|
||||
Assert.False(result.Truncated);
|
||||
Assert.Null(result.ContinuationToken);
|
||||
var only = Assert.Single(result.Children);
|
||||
Assert.Equal("Tag2", only.DisplayName);
|
||||
Assert.Equal(BrowseNodeClass.Variable, only.NodeClass);
|
||||
Assert.False(only.HasChildren);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseLeaf_ReturnsEmpty_NoContinuation()
|
||||
{
|
||||
await using var client = (StubOpcUaClient)await ConnectedStubAsync();
|
||||
|
||||
var result = await client.BrowseChildrenAsync(parentNodeId: "Folder2");
|
||||
|
||||
Assert.False(result.Truncated);
|
||||
Assert.Null(result.ContinuationToken);
|
||||
Assert.Empty(result.Children);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user