feat(dcl): BrowseNext continuation paging + StubOpcUaClient canned browse (T15)
This commit is contained in:
@@ -10,21 +10,28 @@ public interface IBrowsableDataConnection
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the immediate children of <paramref name="parentNodeId"/>, or
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root (OPC UA ObjectsFolder).</param>
|
/// <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>
|
/// <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(
|
Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||||
string? parentNodeId,
|
string? parentNodeId,
|
||||||
|
string? continuationToken = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <param name="Children">Child nodes returned by the server in browse order.</param>
|
/// <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(
|
public record BrowseChildrenResult(
|
||||||
IReadOnlyList<BrowseNode> Children,
|
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="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>
|
/// <param name="DisplayName">Human-readable display name from the server's DisplayName attribute.</param>
|
||||||
|
|||||||
@@ -132,15 +132,21 @@ public interface IOpcUaClient : IAsyncDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enumerates the immediate children of <paramref name="parentNodeId"/>
|
/// 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
|
/// <see cref="ConnectionNotConnectedException"/> when the session is not
|
||||||
/// currently up.
|
/// currently up.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root.</param>
|
/// <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>
|
/// <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(
|
Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||||
string? parentNodeId,
|
string? parentNodeId,
|
||||||
|
string? continuationToken = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +241,48 @@ internal class StubOpcUaClient : IOpcUaClient
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<BrowseChildrenResult> BrowseChildrenAsync(
|
public Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||||
string? parentNodeId, CancellationToken cancellationToken = default)
|
string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
|
||||||
=> throw new NotImplementedException();
|
{
|
||||||
|
// 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>
|
/// <summary>Disposes this stub client and marks the connection as closed.</summary>
|
||||||
/// <returns>A completed <see cref="ValueTask"/>.</returns>
|
/// <returns>A completed <see cref="ValueTask"/>.</returns>
|
||||||
|
|||||||
@@ -254,11 +254,17 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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)
|
if (_status != ConnectionHealth.Connected || _client is null)
|
||||||
throw new ConnectionNotConnectedException($"MxGateway connection is not connected (status: {_status}).");
|
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 (children, truncated) = await _client.BrowseChildrenAsync(parentNodeId, cancellationToken);
|
||||||
var nodes = children
|
var nodes = children
|
||||||
.Select(c => new BrowseNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
|
.Select(c => new BrowseNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
|
||||||
|
|||||||
@@ -298,8 +298,9 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<BrowseChildrenResult> BrowseChildrenAsync(
|
public Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||||
string? parentNodeId,
|
string? parentNodeId,
|
||||||
|
string? continuationToken = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
=> _client!.BrowseChildrenAsync(parentNodeId, cancellationToken);
|
=> _client!.BrowseChildrenAsync(parentNodeId, continuationToken, cancellationToken);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> WriteBatchAndWaitAsync(
|
public async Task<bool> WriteBatchAndWaitAsync(
|
||||||
|
|||||||
@@ -701,9 +701,22 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
|
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
|
||||||
: configured;
|
: 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 />
|
/// <inheritdoc />
|
||||||
public async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BrowseChildrenAsync(
|
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
|
// Mirror the SubscribeAsync/ReadAsync wrap idiom: snapshot the session
|
||||||
// reference once, fail fast with a typed exception if the link is
|
// reference once, fail fast with a typed exception if the link is
|
||||||
@@ -723,27 +736,83 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
? ObjectIds.ObjectsFolder
|
? ObjectIds.ObjectsFolder
|
||||||
: NodeId.Parse(parentNodeId);
|
: NodeId.Parse(parentNodeId);
|
||||||
|
|
||||||
// NodeClassMask intentionally excludes ReferenceType, View, Variable-
|
// No token → fresh browse of the node. A non-empty token → continue a
|
||||||
// Type, ObjectType, DataType. UI only needs Objects (navigable),
|
// prior browse via BrowseNext, falling back to a fresh browse if the
|
||||||
// Variables (selectable), Methods (display-only).
|
// server has invalidated the continuation point.
|
||||||
var nodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
|
if (string.IsNullOrEmpty(continuationToken))
|
||||||
|
{
|
||||||
|
return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
// requestedMaxReferencesPerNode: cap the server's per-call references so a
|
try
|
||||||
// huge flat folder cannot return an unbounded set. 500 leaves headroom for
|
{
|
||||||
// the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
|
// SDK overload: BrowseNextAsync(session, requestHeader, ByteStringCollection
|
||||||
// even with long string NodeIds; a non-empty continuation point surfaces as
|
// continuationPoints, bool releaseContinuationPoint, ct) → returns
|
||||||
// Truncated, prompting manual entry rather than auto-paging.
|
// (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(
|
var (_, continuationPoint, references) = await session.BrowseAsync(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
nodeToBrowse,
|
nodeToBrowse,
|
||||||
500u,
|
BrowseMaxReferencesPerNode,
|
||||||
BrowseDirection.Forward,
|
BrowseDirection.Forward,
|
||||||
ReferenceTypeIds.HierarchicalReferences,
|
ReferenceTypeIds.HierarchicalReferences,
|
||||||
true,
|
true,
|
||||||
nodeClassMask,
|
BrowseNodeClassMask,
|
||||||
cancellationToken).ConfigureAwait(false);
|
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 refs = references ?? new ReferenceDescriptionCollection();
|
||||||
var children = new List<Commons.Interfaces.Protocol.BrowseNode>(refs.Count);
|
var children = new List<Commons.Interfaces.Protocol.BrowseNode>(refs.Count);
|
||||||
foreach (var r in refs)
|
foreach (var r in refs)
|
||||||
@@ -755,22 +824,22 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
HasChildren: r.NodeClass == NodeClass.Object));
|
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
|
// Writable so the node picker can show types. Best-effort: ONE batched
|
||||||
// ReadAsync over (DataType, ValueRank, UserAccessLevel) for every
|
// ReadAsync over (DataType, ValueRank, UserAccessLevel) for every
|
||||||
// Variable child; on ANY failure we leave the three fields null and
|
// Variable child; on ANY failure we leave the three fields null and
|
||||||
// return the children exactly as built above. Non-Variable nodes are
|
// 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)
|
children = await EnrichVariableTypeInfoAsync(session, refs, children, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
// A non-empty continuation point means the server had more refs than
|
// A non-empty continuation point means the server has more refs than
|
||||||
// our requestedMaxReferencesPerNode cap. The UI surfaces a "more
|
// were returned on this page. Surface it as an opaque Base64 token the
|
||||||
// children, type the node id manually" hint rather than auto-paging;
|
// caller passes back to fetch the next page via BrowseNext (T15).
|
||||||
// BrowseNext is not invoked here. Discarding the continuation point
|
var hasMore = continuationPoint != null && continuationPoint.Length > 0;
|
||||||
// is acceptable because the server expires it on session close.
|
var token = hasMore ? Convert.ToBase64String(continuationPoint!) : null;
|
||||||
var truncated = continuationPoint != null && continuationPoint.Length > 0;
|
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, hasMore, token);
|
||||||
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, truncated);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Commons.Interfaces.Protocol.BrowseNodeClass MapNodeClass(NodeClass nc) => nc switch
|
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),
|
new BrowseNode("ns=2;s=B", "B", BrowseNodeClass.Object, HasChildren: true),
|
||||||
};
|
};
|
||||||
((IBrowsableDataConnection)adapter)
|
((IBrowsableDataConnection)adapter)
|
||||||
.BrowseChildrenAsync(null, Arg.Any<CancellationToken>())
|
.BrowseChildrenAsync(null, Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new BrowseChildrenResult(children, Truncated: false));
|
.Returns(new BrowseChildrenResult(children, Truncated: false));
|
||||||
|
|
||||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
||||||
@@ -139,7 +139,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
|
|||||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||||
|
|
||||||
((IBrowsableDataConnection)adapter)
|
((IBrowsableDataConnection)adapter)
|
||||||
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromException<BrowseChildrenResult>(
|
.Returns(Task.FromException<BrowseChildrenResult>(
|
||||||
new ConnectionNotConnectedException("OPC UA session is not connected.")));
|
new ConnectionNotConnectedException("OPC UA session is not connected.")));
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
|
|||||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||||
|
|
||||||
((IBrowsableDataConnection)adapter)
|
((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)));
|
.Returns(Task.FromException<BrowseChildrenResult>(new NotSupportedException(reason)));
|
||||||
|
|
||||||
_factory.Create("MxGateway", Arg.Any<IDictionary<string, string>>())
|
_factory.Create("MxGateway", Arg.Any<IDictionary<string, string>>())
|
||||||
@@ -226,7 +226,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
|
|||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
|
||||||
((IBrowsableDataConnection)adapter)
|
((IBrowsableDataConnection)adapter)
|
||||||
.BrowseChildrenAsync(null, Arg.Any<CancellationToken>())
|
.BrowseChildrenAsync(null, Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new BrowseChildrenResult(bigList, Truncated: false));
|
.Returns(new BrowseChildrenResult(bigList, Truncated: false));
|
||||||
|
|
||||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
|
||||||
|
|||||||
+2
-2
@@ -25,7 +25,7 @@ public class OpcUaDataConnectionBrowseTests
|
|||||||
var expected = new BrowseChildrenResult(
|
var expected = new BrowseChildrenResult(
|
||||||
new[] { new BrowseNode("ns=2;s=X", "X", BrowseNodeClass.Variable, false) },
|
new[] { new BrowseNode("ns=2;s=X", "X", BrowseNodeClass.Variable, false) },
|
||||||
Truncated: 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);
|
.Returns(expected);
|
||||||
|
|
||||||
var adapter = new OpcUaDataConnection(factory, NullLogger<OpcUaDataConnection>.Instance);
|
var adapter = new OpcUaDataConnection(factory, NullLogger<OpcUaDataConnection>.Instance);
|
||||||
@@ -36,6 +36,6 @@ public class OpcUaDataConnectionBrowseTests
|
|||||||
var actual = await adapter.BrowseChildrenAsync("ns=2;s=Parent");
|
var actual = await adapter.BrowseChildrenAsync("ns=2;s=Parent");
|
||||||
|
|
||||||
Assert.Same(expected, actual);
|
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